Skip to content

[core] Negate predicates like Java so NULL rows stay excluded - #1145

Open
JeonDaehong wants to merge 14 commits into
apache:mainfrom
JeonDaehong:fix-negate-null
Open

JeonDaehong wants to merge 14 commits into
apache:mainfrom
JeonDaehong:fix-negate-null

Conversation

@JeonDaehong

Copy link
Copy Markdown
Contributor

Purpose

Linked issue: close #1143

Predicate::negate wrapped leaves in Not, and Not was evaluated as !inner, so a NULL input made NOT(p = 'a') true. Reads returned those rows and delete_by_predicate deleted them. Java flips the leaf instead (Equal.negate() is NotEqual), which is false for NULL.

Brief change log

  • Predicate::negate now does what Java Predicate.negate does: flip the leaf operator (=/!=, </>=, <=/>, IS NULL/IS NOT NULL, IN/NOT IN, BETWEEN/NOT BETWEEN) and push through AND/OR with De Morgan.
  • DataFusion: the NOT BETWEEN coverage check in scan.rs now matches the new shape of the negated filter (x < lo OR x > hi instead of NOT(x >= lo AND x <= hi)).
  • Leaves with no negated operator here (STARTS_WITH, ENDS_WITH, CONTAINS, LIKE, array ops) stay as Not(leaf). eval_row and the residual row filter now return false for those when the input is NULL, same as Java NotStartsWith/NotLike/... .

Tests

  • negated_predicates_keep_null_rows and negated_data_predicate_keeps_null_rows cover the cases from the issue on the partition and DV delete paths and on reads. Both fail on main.
  • Unit tests for negate and for NULL evaluation in eval_row and the residual filter.
  • Changed existing tests:
    • test_file_index_nested_not_with_added_column_falls_back now expects [1] instead of [1, 2]. added is NULL in the old file, so for id 2 the predicate is NOT(true AND unknown). Java drops that row too.
    • test_not / test_display_not, three DataFusion pushdown tests, the global index exactness test and the stats fail-open test asserted the old Not shape. The last two now build Predicate::Not directly so they keep testing the Not branch.

fmt and clippy (1.98.0, -D warnings) pass. cargo test --no-fail-fast -p paimon -p paimon-datafusion -p paimon-c: 5804 passed. The 39 failures are all TableNotExist / table not found for the fixture tables CI provisions, plus 2 that need the lumina library. The ones I checked fail the same way on main.

Not covered here: a Predicate::Not around AND/OR built by hand (not through negate) is still evaluated as !inner. Nothing in core builds that today, but I can add it if you'd like.

API and Format

No API change. Predicate::negate returns a different but equivalent tree for non-NULL inputs.

Documentation

None.

@JeonDaehong

Copy link
Copy Markdown
Contributor Author

@JingsongLi @leaves12138 could you take a look when you have time? Fixes #1143: delete_by_predicate with a negated predicate was also deleting rows where the column is NULL.

@JeonDaehong
JeonDaehong marked this pull request as draft October 17, 2026 06:24
@JeonDaehong

Copy link
Copy Markdown
Contributor Author

Moved to draft until CI is green. I couldn't run the docker fixtures and the Go/Python suites locally. Will mark it ready once they pass.

@JeonDaehong
JeonDaehong marked this pull request as ready for review October 17, 2026 06:42
@JeonDaehong

Copy link
Copy Markdown
Contributor Author

CI is green, ready for review.

@JingsongLi JingsongLi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requirement fit: SUPPORTED. Implementation: FINDINGS at 0a5bdd9. Fixing NULL rows being selected/deleted by negated predicates has clear end-to-end value. The ordinary NULL and added string-negation scenarios pass, but two additional public-API regression tests fail on this head and pass on base 9568f39: floating bitmap + vector Top-1 returns a row that violates the filter, and a NaN partition is silently excluded. Details are inline. Local verification also ran the core library suite (4146 passed, 6 ignored); after provisioning the actual Spark warehouse, all 358 DataFusion library tests passed. Both new regression tests were also run unchanged against the baseline worktree and passed there.

Some(match self {
Self::IsNull => Self::IsNotNull,
Self::IsNotNull => Self::IsNull,
Self::Eq => Self::NotEq,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Refine floating bitmap candidates before ranking normalized NOT filters

Turning NOT(f = 1) into NotEq now enters the floating bitmap scalar-index path. BitmapGlobalIndexReader::query deliberately returns all non-null rows for floating NotEq, but matching_ranges_with_exactness labels this leaf exact, so scalar_matched_row_ids skips refinement before vector ranking. I reproduced this through public write/commit/index/search APIs: two rows have f=[1,2], vectors [[1,0],[0,1]], a bitmap index on f, and an ivf-flat index on the vectors. Searching [1,0] with limit 1 and Predicate::negate(equal(f,1)) returns row 0 (f=1), which violates the filter; expected row 1. The same test passes on base 9568f39a, and explicitly constructing raw Predicate::Not still returns row 1 on this head. Propagate floating bitmap candidate inexactness to the scanner and refine before ranking; add this filtered Top-K regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, reproduced. Fixed in eff224e in two places:

  • negate now keeps Not for FLOAT/DOUBLE comparisons (see the NaN comment), so NOT(f = 1) stays out of the bitmap NotEq path.
  • The same wrong Top-1 also came from a plain not_equal(f, 1) or greater_than(f, 1.5), with no negation involved. is_exact_predicate now treats FLOAT/DOUBLE leaves that the bitmap answers with every non-null row (is_bitmap_floating_residual_sensitive_op) as inexact, so they are refined before ranking.

floating_bitmap_candidates_are_refined_before_top_k covers all three filters. Before the change it returned row 0, now row 1.

Self::IsNotNull => Self::IsNull,
Self::Eq => Self::NotEq,
Self::NotEq => Self::Eq,
Self::Lt => Self::GtEq,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve NaN rows when negating floating partition comparisons

These operator flips assume complementary ordering, but native datum_cmp uses partial_cmp for FLOAT/DOUBLE, so NaN makes both < and >= false. I reproduced a scan with rows (id,f)=[(0,0.0),(1,NaN),(2,2.0)] and Predicate::negate(less_than(f,1.0)): this head returns [1,2] when f is a data column but only [2] when partitioned by f, because the normalized GtEq prunes the NaN partition. The identical test returns [1,2] in both layouts on base 9568f39a. Java's floating comparison and the Arrow residual path retain the NaN row here. Make native floating comparison compatible with those semantics, or retain NULL-aware NOT until the complements are sound, and cover partitioned/nonpartitioned NaN reads.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, reproduced: the partitioned read returned [2]. I kept Not for FLOAT/DOUBLE comparisons in negate rather than changing datum_cmp, since that would also change plain < / > on NaN partitions, which seems like a separate change. Not(leaf) is false only for NULL, and NaN < 1.0 is false, so the NaN row is kept, as in Java.

negated_float_comparison_keeps_nan_rows checks the read and delete_by_predicate, partitioned and not.

@JeonDaehong

Copy link
Copy Markdown
Contributor Author

@JingsongLi thanks for the careful review. Both are fixed in eff224e, details inline.

I also rebased onto main. After #1153, negate handles NestedLeaf the same way as Leaf, and the NULL guard for a kept Not is shared by eval_row and the Arrow residual (Predicate::not_null_guard).

Checked with the ci.yml steps: fmt, clippy (fulltext,vortex), msrv 1.94, paimon unit (4983 passed), rest-server, integration with make docker-up, DataFusion (+vortex), Python (579 passed) and Go bindings.

@JingsongLi JingsongLi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requirement fit: SUPPORTED. Implementation: FINDINGS at eff224e. The prior floating-bitmap Top-1 and NaN-partition negation regressions are fixed on their covered paths; both added regression tests pass. A new public-API BTree + vector-search probe now returns no rows on this head and passes unchanged on baseline 46e6871. Details inline.

Local validation: 4,250 core library tests, 14 native predicate read/delete tests, 358 DataFusion library tests, 46 SQL read tests, and 5 C API checks passed. The actual Spark warehouse was provisioned. Two additional SQL reads fail only because liblumina_py.dylib is unavailable; lumina-data 0.1.0 supplies only a Linux x86_64 wheel, so these two native-library checks could not be completed on this macOS ARM host. Formatting and clean integration with current main were also checked.

Comment on lines +399 to +400
&& !(matches!(data_type, DataType::Float(_) | DataType::Double(_))
&& super::bitmap_global_index_format::is_bitmap_floating_residual_sensitive_op(*op))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep exact floating BTree answers exact

This bitmap-specific downgrade currently applies to BTree results as well. BTree already implements floating != and ranges using the same bit-preserving total ordering as the residual filter, so those answers are exact. With the supported global-index.filter.refine-from-data=false option, scalar_matched_row_ids excludes all candidates that this method marks inexact. I reproduced the regression using actual Parquet data with finite f=[1.0, 2.0], a scalar BTree on f, an ivf-flat vector index, and filtered Top-1 with f != 1.0: baseline 46e6871 returns id 1, while this head returns no rows. No NaN or predicate negation is involved. Please derive exactness from the contributing index entries/readers and downgrade the floating bitmap fallback specifically, retaining exact BTree results; add a regression with refinement disabled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the downgrade was keyed on the predicate, not on the index that answered it. Fixed in ae3660e. evaluate_leaf now marks a leaf inexact only when a bitmap entry answered a floating range or negation, so BTree results stay exact. I reverted the check in is_exact_predicate.

floating_bitmap_candidates_are_refined_before_top_k now also runs on BTree, with and without refine-from-data=false. Before this change, BTree + refine-from-data=false returned [].

I also rebased onto current main.

@JingsongLi JingsongLi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requirement fit: SUPPORTED. Implementation: FINDINGS at ae3660e. Fixing negated predicates selecting/deleting NULL rows has clear end-to-end value. The earlier bitmap Top-K, NaN-partition and pure-BTree regressions are covered and their tests pass, but two additional public-API regressions remain: a pruned all-NULL Bitmap shard downgrades exact BTree hits, and raw double-NOT selects NULL after schema evolution. Both tests fail on this head and pass unchanged on exact base cea0caa; details inline.

Local validation: 4,449 core library tests (7 ignored), 358 DataFusion library tests, 14 native predicate read/delete tests and 46 SQL read tests passed. Two other SQL reads could not run successfully because this macOS host lacks liblumina_py.dylib. The current CI check failure is the unchanged row_variant_interop README license header.

// A bitmap answers floating ranges and negations with every non-null
// row (see `bitmap_meta_may_match`), so those results are candidates
// only. Sorted indexes answer them exactly.
let bitmap_superset = entries

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve exactness when every Bitmap entry was pruned

This flag considers every selected entry before file-level pruning. I reproduced a public write/index/search scenario that builds Bitmap(f) over two NULL rows, appends finite f=[2,3], then builds BTree(f) and an IVF-flat vector index. For f > 1.5, the Bitmap shard is pruned by its all-NULL metadata and the remaining BTree answer is exact. Nevertheless bitmap_superset downgrades it, so Top-1 with global-index.filter.refine-from-data=false returns [] instead of row 2. The same test passes unchanged on base cea0caa; refinement=true also returns row 2 on this head. Derive candidate inexactness from Bitmap entries that actually contribute a superset, retaining exactness when they are all pruned.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks (189d400). An all-NULL bitmap shard is pruned before it answers anything, so it shouldn't count either. The flag now only considers bitmap entries that aren't only_nulls(). I added your Bitmap-on-NULLs + BTree + IVF-flat case with refinement off. Without the change it returns [], with it row 2.

Comment thread crates/paimon/src/arrow/residual.rs Outdated
})))
let negated =
boolean_mask_from_predicate(mask.len(), |row_index| !mask.value(row_index));
if let Some(guard) = inner.not_null_guard() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep raw double negation from selecting NULL rows

The new guard makes an inner Not(leaf) false for NULL, but an outer Not has no guard when its child is another Not, so it turns that false back into true. Public Predicate::Not is a logical-negation variant and existing code accepts raw nested Not trees. A public read probe writes value=[NULL,1,2], persists a supported INT-to-BIGINT schema evolution, and reads with Not(Not(value = 1)): this head returns ids [0,1], while exact base cea0caa returns only [1]. This is a newly introduced double-NOT regression, separate from the pre-existing raw Not(AND/OR) limitation. Normalize/eliminate double negation before evaluation, or preserve NULL semantics through nested Not consistently in both residual and BinaryRow evaluation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the outer Not had no guard when its child was another Not. not_null_guard now passes through a nested Not, so Not(Not(v = 1)) takes v's guard in both the residual and row evaluation. The new test reads and deletes with it, with and without the INT→BIGINT widening, and returns [1] again ([0, 1] before).

Side note: clippy on the rebased branch fails on an unused BinaryRow import in table_upsert.rs from #1198, which this PR doesn't touch.

@JingsongLi JingsongLi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requirement fit: SUPPORTED. Implementation: FINDINGS at 189d400. Both prior public reproductions now pass: the all-NULL Bitmap case retains exact BTree hits, and raw double-NOT excludes NULL after schema widening. Two further compositions still regress: an equality-pruned finite Bitmap downgrades exact BTree results, and an outer compound NOT newly selects a NULL row. Both new public-API probes fail on this head and pass unchanged on exact base fae8acf; details inline.

Validation: predicate, residual, vector and scanner unit suites passed; 17 native read/delete and prior-regression tests passed. After restoring the real Spark fixtures into an isolated warehouse, all 358 DataFusion unit tests and 47 SQL read tests passed. Two Lumina-dependent SQL reads were excluded on this macOS host; one existing test is ignored. Formatting/diff checks and merge with current main passed. Current CI is unverified because the Code Review backend cannot connect to GitHub.

// row (see `bitmap_meta_may_match`), so those results are candidates
// only. Sorted indexes answer them exactly.
// An all-NULL bitmap shard is pruned before it answers anything.
let bitmap_superset = entries.iter().any(|entry| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Derive Bitmap candidate status after all conjunct pruning

Excluding only all-NULL entries still downgrades exact results when another conjunct prunes a finite Bitmap. A public probe builds Bitmap(f) over f=[0,1], appends f=[2,3], then builds BTree(f) and IVF-flat. For f > 1.5 AND f = 2, the equality metadata check completely prunes the old Bitmap, and the surviving BTree answers both predicates exactly. This wrapper nevertheless sees the non-NULL Bitmap and marks the result inexact: Top-1 with refine-from-data=false returns [] instead of row 2. The identical test passes on base fae8acf; refinement=true and no-refinement equality-only controls also return row 2 on this head. Compute inexactness from retained Bitmap query plans/candidates after every conjunct is considered, rather than from all selected entries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1d42473. Exactness is now decided after every conjunct is applied, from the bitmap shards that actually stay in the plan. I added your f > 1.5 AND f = 2 case as bitmap_pruned_by_another_conjunct_keeps_btree_answer_exact. It returns [] without the change and [2] with it.

Comment thread crates/paimon/src/arrow/residual.rs Outdated
})))
let negated =
boolean_mask_from_predicate(mask.len(), |row_index| !mask.value(row_index));
if let Some(guard) = inner.not_null_guard() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve NULL semantics when negations are composed under AND

The inner NOT now maps NULL to false, but an outer NOT around a compound expression receives no guard and negates that collapsed false to true. A public raw predicate NOT(NOT(value = 1) AND id >= 0) on value=[NULL,1,2] now returns ids [0,1] after supported INT-to-BIGINT schema evolution; the unchanged probe on base fae8acf returns only [1]. Although general raw NOT(AND/OR) already has a documented limitation, this particular input was correctly excluded before this change and newly admits NULL, violating with_filter's exact row-filter contract. Normalize compound NOT through De Morgan before losing UNKNOWN, or preserve NULL state through the outer evaluation, consistently in residual and BinaryRow paths; cover a nested-NOT-under-compound regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6e9d54e. NOT is now pushed down to the leaves with De Morgan before evaluation, in both the residual (evaluate_negated_mask) and BinaryRow (eval_row_negated) paths. Only leaves get negated, and each keeps its NULL guard. raw_double_negation_keeps_null_rows_out now also covers NOT(NOT(v = 10) AND id >= 0), with and without widening.

I also checked both paths against a three-valued reference on 20k random NOT/AND/OR trees over nullable columns: 0 mismatches now, about 3k before.

@JingsongLi JingsongLi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requirement fit: SUPPORTED. Implementation: FINDINGS at 6e9d54e. The previous four retained NULL/NOT/mixed-index reproductions now pass, including compound NOT and metadata-pruned Bitmap shards. One remaining mixed-index case still loses an exact BTree result when the Bitmap shard produces an empty posting; details inline.

Validation: 286 predicate-related unit tests; 26 DE-vector, 47 Arrow residual and 77 global-index tests (1 ignored); 15 real-table predicate/delete tests plus the four retained public-API probes; 358 DataFusion units and 47 SQL read tests (1 ignored) passed. Two Lumina-dependent SQL tests were excluded because the host lacks the native library. The new empty-posting real-index/vector-search probe fails on this head and passes unchanged on exact base 4cf3437; equality-only/no-refinement and conjunction/with-refinement controls pass on this head. Formatting, diff checks and merge with current main are clean. Current CI could not be verified because the Code Review backend cannot connect to GitHub.

all_matching_ranges.push(RowRange::new(entry.row_range_start, entry.row_range_end));
continue;
}
bitmap_retained |= matches!(entry.index_type, GlobalIndexFileKind::Bitmap);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Determine Bitmap contribution after querying the posting

A Bitmap shard can survive metadata pruning yet contribute no rows. Setting this flag before querying still downgrades an exact BTree answer in that case. I reproduced with an old finite Bitmap on f=[0,2], then appended f=[1,3] and built BTree(f) plus IVF: for f > 0.5 AND f = 1, the Bitmap min/max cannot prune f=1, but its actual equality posting is empty, so only the exact BTree hit (row ID 2) remains. With global-index.filter.refine-from-data=false, this head returns [] instead of [2]; exact base 4cf3437 returns [2]. On this head, equality alone without refinement and the conjunction with refinement both return [2]. This is not a contributing inexact Bitmap candidate: its query result is empty. Track whether Bitmap actually adds nonempty ranges when combining query results, rather than marking its planned query as a contribution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, it was set when the query was planned, not when rows came back. now it's only set when a bitmap shard actually returns ranges (1f119c2). added your case as a test: bitmap shard f=[1,3], BTree on the later rows, f > 1.5 AND f = 2. it returns [] without the fix and [2] with it.

crate::table::merge_row_ranges(all_ranges),
coverage(),
fully_evaluated,
fully_evaluated && !(bitmap_retained && bitmap_superset_ops),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Scope Bitmap exactness downgrades to the vector-covered domain

This flag also counts Bitmap candidates outside the range the vector index will rank. In FULL/DETAIL mode, Raw splits carry their scalar indexes, which are combined with the Index split's scalar entries. scalar_matched_row_ids obtains this global exactness flag before intersecting the returned ranges with vector_ranges. Consequently, a Bitmap candidate removed by that intersection can still make the remaining exact BTree hits inexact, and global-index.filter.refine-from-data=false then discards them.

I reproduced this through public write/index/search APIs: partition by id, write id=0, f=2.0 and build BTree(f) plus IVF-flat(embedding); append id=1, f=0.0 and build Bitmap(f) only for that partition, without a vector index. Top-1 with f > 1.5 returns [] in FULL/DETAIL with refinement disabled, instead of [0]. The Bitmap covers [1,1], entirely outside the vector index's [0,0] domain. FAST with refinement disabled and all refinement-enabled controls return [0]. The identical test passes all six combinations on base 370428b0 and fails the two FULL/DETAIL cases on head 1f119c29.

Please determine exactness within the actual vector-covered ranges, or preserve per-range inexactness through the intersection, and add this partial-vector-coverage regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, a flag can't say where the candidates are. the bitmap part is now kept as row ranges instead of a bool, and both callers only treat the answer as inexact if those ranges overlap the rows they actually use: the vector ranges in scalar_matched_row_ids, and the decided rows in the full-text filter (c0bde05). added your partitioned case as a test, it returns [0] for all six mode/refine combinations, and [] in FULL/DETAIL with refine off before this commit.

Predicate::negate wrapped leaves in Not, which was evaluated as !inner, so
NOT(p = 'a') selected rows where p is NULL. Reads returned them and
delete_by_predicate deleted them.

Flip the leaf operator and push through AND/OR like Java Predicate.negate.
Leaves without a negated operator stay as Not, which now evaluates to false
for NULL input.

Closes apache#1143
…ates

Negating a FLOAT/DOUBLE comparison keeps Not, since NaN makes the flipped
operator non-complementary. Scalar index exactness treats FLOAT/DOUBLE
leaves that the bitmap index answers with every non-null row as inexact,
so filtered Top-K refines them before ranking.
Only a bitmap answers floating ranges and negations with every non-null
row, so mark a leaf inexact only when a bitmap entry answered it.
NOT(NOT(x)) now takes x's NULL guard, so a raw double negation keeps NULL
rows out. A bitmap entry that holds only NULLs is pruned before it answers,
so it no longer marks an exact BTree result as candidates.
A leaf yields false for NULL, so negating a compound result turned
NOT(NOT(v = 1) AND id >= 0) true for a NULL v.

@JingsongLi JingsongLi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requirement fit: SUPPORTED. Implementation: FINDINGS at c0bde05. The NULL predicate/deletion fix has clear end-to-end value, and the previous outside-vector-domain exactness case is addressed. One mixed Bitmap/BTree regression remains: disabling refinement discards exact BTree hits alongside Bitmap candidates; details inline.

Validation in an isolated worktree, Rust 1.98.0 on macOS:

  • Core library, serial: 4,566 passed, 7 ignored. A parallel run first hit a timing-sensitive, unchanged shared-cache lock assertion; the isolated rerun and full serial run passed.
  • Predicate read/delete integration tests: all 15 passed.
  • DataFusion library with the committed Java golden warehouse supplied through PAIMON_TEST_WAREHOUSE: all 362 passed. The initial 7 missing-fixture failures disappear with that warehouse.
  • An independent public table-write, scalar/vector-index-build and Top-K/read probe fails on this head in FAST/FULL/DETAIL with refinement disabled; all enabled-refinement controls return the expected id 2. The identical probe passes all six combinations on exact merge-base fb5e8f7.
  • Formatting and diff checks pass; current GitHub CI is green.

Before production, also resolve the merge conflict with current main 8df32eb in crates/paimon/src/table/de_vector_read.rs, preserving both resource-budget handling and the corrected exact/candidate range semantics.

let ranges = intersect_sorted_ranges(&ranges, vector_ranges);
if exact {
// Candidates outside the rows this search ranks do not make it inexact.
if exact && intersect_sorted_ranges(&candidate_ranges, &ranges).is_empty() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve exact BTree hits alongside Bitmap candidates

The new condition downgrades the entire scalar answer when any candidate-only range remains. With global-index.filter.refine-from-data=false, the following branch then returns an empty set, including disjoint rows already proven by an exact BTree index. The new scanner contract distinguishes candidate-only rows; disabling refinement should exclude those rows, rather than the exact remainder.

An independent public write/build-index/search/read probe writes rows 0/1 with f=0/1, builds Bitmap(f), appends rows 2/3 with f=2/3 and builds BTree(f), then builds IVF-flat over all embeddings. For f > 1.5, query [1,0], top-1 is exact BTree row 2; Bitmap rows 0/1 are only candidates and are farther away. This head returns [] in FAST, FULL and DETAIL with refinement disabled, while all enabled-refinement controls return [2]. The identical probe passes all six combinations on exact merge-base fb5e8f7.

When exact is true, retain ranges - candidate_ranges if refinement is disabled; otherwise refine candidate rows without losing exact ones. The full-text consumer at filter.rs:197 has the same all-or-nothing classification and should be checked as part of the fix. Please cover mixed candidate/exact shards within the vector-covered domain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 92e4758. With an exact answer only the rows in candidate_ranges are refined or dropped now, and filter.rs got the same change. Added your bitmap-then-BTree case for vector search plus a full-text one, both returned [] before.

@JingsongLi JingsongLi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requirement fit: SUPPORTED. Implementation: FINDINGS at 92e4758. The previous disjoint Bitmap/BTree finding is addressed, but candidate tracking now loses a match already proven by another OR arm; details inline.

Local validation in an isolated worktree with Rust 1.98.0:

  • Core library, serial: 4,805 passed, 6 ignored.
  • Predicate read/delete integration: all 15 passed.
  • DataFusion library using the committed Java golden warehouse: all 377 passed.
  • An independent public write / Bitmap and IVF-flat index build / Top-K search / read probe fails with f < 0 OR f = 1 when refinement is disabled in FAST, FULL and DETAIL. The exact f = 1 controls and all enabled-refinement controls return [1]. The unchanged probe passes every combination on exact merge-base ed9df78.
  • Formatting and diff checks pass; local merge-tree integration with current main 062f1bc is conflict-free.

The current CI check job also fails dependency policy on inherited thrift 0.17.0 / RUSTSEC-2026-0435: https://github.com/apache/paimon-rust/actions/runs/41451516623/job/125297595766 . This is outside the PR's code diff; current main's Arrow/Parquet upgrade removes thrift. Include that baseline and obtain green required CI after fixing the OR regression before production.

Some(child_result) => {
fully_evaluated &= child_result.fully_evaluated;
all_ranges.extend(child_result.row_ranges);
candidate_ranges.extend(child_result.candidate_ranges);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve exact OR matches when another arm returns candidates

Unioning child candidate ranges here marks a row uncertain even when another OR arm proves it matches. With a floating Bitmap index over f=[0,1], f < 0 OR f = 1 gets both non-null rows as candidates from the range arm, while equality proves the f=1 row. With global-index.filter.refine-from-data=false, the vector consumer subtracts the combined candidates and returns [] instead of the proven match. I reproduced this through public write/index-build/search/read APIs in FAST, FULL and DETAIL (query [1,0], top-1, with the f=1 row nearest); exact-only and refinement-enabled controls pass. The identical probe passes on merge-base ed9df78, so this is introduced by the new candidate propagation. For OR, retain the union of proven child matches and exclude those rows from the final candidate set, respecting each child's exactness. Please cover overlapping candidate/exact OR branches in vector and full-text consumers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, reproduced it. OR now keeps the rows a fully evaluated arm proves and removes them from the candidate set. Added tests for both arm orders in the vector and full-text paths; they fail before the fix. Also merged main for the thrift check.

Some(child_result) => {
fully_evaluated &= child_result.fully_evaluated;
if child_result.fully_evaluated {
exact_ranges.extend(exclude_row_ranges(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep OR range subtraction linear for fragmented bitmap hits

For a nullable floating Bitmap index, f < 0 returns every non-null row as a candidate. With alternating 1.0 and NULL, both range lists passed here consist of the same K disjoint singleton intervals. exclude_row_ranges restarts its scan of the cuts for each base interval, so this one OR arm performs K(K+1)/2 comparisons. The final subtraction can repeat the cost. A million-row input with this distribution reaches about 125 billion comparisons for one subtraction.

I reproduced the scaling through public write, Bitmap index build and scan planning with f < 0 OR f = 1. On my macOS debug build, planning took approximately 16 ms, 187 ms and 791 ms for 2,048, 8,192 and 16,384 disjoint intervals respectively. The exact-only control planned the same ranges in 2 to 4 ms. This is on the normal scalar index path and is also shared by vector and full-text search.

Please use a two-pointer difference over sorted, merged ranges. Empty or identical inputs can also avoid the subtraction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. exclude_row_ranges now skips cuts that end before the current segment and stops at the first one past it, so it's linear; the OR arm also skips the subtraction when a child has no candidates. For K disjoint singletons minus themselves it goes from 437 ms to 1.3 ms at K=16,384 (debug build), and a random comparison against the old version gives the same output.

};
let negated =
boolean_mask_from_predicate(mask.len(), |row_index| !mask.value(row_index));
if let Some(guard) = predicate.not_null_guard() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve NULL semantics when a Mosaic file physically omits the field

The new guard excludes a stored NULL for NOT starts_with(name, 'a'). A same-schema Mosaic file may omit that logical field physically. Mosaic remaps against the columns actually stored, and remap_predicate in arrow/filtering.rs folds the missing positive leaf to AlwaysFalse and then flips the retained Not to AlwaysTrue. The guard never sees it.

I reproduced this with the existing sparse Mosaic fixture, a nullable logical new_name column absent from the file, and Predicate::negate(starts_with("new_name", "a")). The reader returned all six fixture IDs [1, 2, 10, 11, 20, 21]. Since the omitted field is NULL for all rows, the expected result is empty.

Please apply the NULL-aware negation rule before missing fields are folded into constants, and add a sparse-file regression for a leaf retained under Not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, reproduced it with the sparse fixture. remap_predicate now pushes the NOT down before folding missing fields, so a missing leaf under NOT becomes AlwaysFalse (IS NULL / IS NOT NULL still flip). Added test_read_predicate_missing_column_under_not_stays_null, which returns all six ids before the fix.

@JingsongLi JingsongLi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One P2 regression remains with overlapping Bitmap/BTree coverage: proven matches are discarded when refinement is disabled. The inline comment includes public-API reproduction steps, controls, and results from the identical probe on the exact base and PR head.

Comment on lines +831 to +832
if bitmap_superset_ops && !bitmap_ranges.is_empty() {
crate::table::merge_row_ranges(bitmap_ranges)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve exact BTree hits when Bitmap coverage overlaps

bitmap_ranges includes rows that the same leaf's BTree result has already proven to match. Different index types are allowed to cover the same field and row range, so these are not necessarily candidate-only rows. With global-index.filter.refine-from-data=false, both search consumers subtract these ranges and discard the proven matches as well. The existing mixed-shard tests cover disjoint Bitmap/BTree rows; this case has overlapping coverage.

I reproduced this through public write, commit, index-build, search and result-read APIs:

  1. Create an append table with id INT, f DOUBLE, text STRING and embedding ARRAY<FLOAT>. Set bucket=-1, row-tracking.enabled=true, data-evolution.enabled=true and global-index.enabled=true.

  2. Write and commit these two rows:

    id f text embedding
    0 1.0 other [0.0, 1.0]
    1 2.0 paimon [1.0, 0.0]
  3. Without appending any more data, build Bitmap(f), then BTree(f), then IVF-flat(embedding) with dimension=2, nlist=1, distance.metric=l2, and a full-text index on text.

  4. Set global-index.filter.refine-from-data=false. Use pb.greater_than("f", Datum::Double(1.5)) as the filter. Run vector Top-1 for query [1.0, 0.0] and full-text Top-1 for query "paimon", then read each result and collect id.

  5. Repeat with vector-index.search-mode, scalar-index.search-mode and full-text-index.search-mode set to fast, full and detail.

Both searches should return [1]. On head 8d30fada4e695ce253b8c93f611c99d72af17f73, both return [] in all three modes. BTree proves row 1 matches, but Bitmap returns both non-null rows as candidates, leaving candidate_ranges=[0,1]; subtracting those candidates removes row 1 too.

The identical probe passes on exact base/merge-base 7a4f3bc89ad0d1ef814db82af1f5d2fba17fb12c:

Scalar indexes Refine from data Base: vector / full-text Head: vector / full-text
BTree only false or true [1] / [1] [1] / [1]
Bitmap + BTree, same rows true [1] / [1] [1] / [1]
Bitmap + BTree, same rows false [1] / [1] [] / []

Each result above holds in FAST, FULL and DETAIL: all 24 combinations pass on base; 6 fail on head. This probe contains no NULLs, NaNs or predicate negation.

Please preserve fully evaluated exact hits when combining readers for a leaf, and remove those proven rows from the Bitmap candidate ranges, as the OR path now does. Keep partial-evaluation/fallback status in that decision, and add an overlapping-coverage regression for both vector and full-text consumers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, reproduced it with Bitmap and BTree built over the same two rows. When the leaf is fully evaluated it now keeps the BTree hits and removes them from the bitmap candidates (only BTree counts as exact). Added overlapping-coverage tests for the vector and full-text paths over fast/full/detail; both return [] before the fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Negated predicates match NULL rows, so delete_by_predicate deletes them

2 participants